﻿/* Copyright 2009 HPDI, LLC
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using Hpdi.VssLogicalLib;
using Hpdi.VssPhysicalLib;
using System.Linq;

namespace Hpdi.Vss2AccuRev
{
    /// <summary>
    /// Enumerates revisions in a VSS database.
    /// </summary>
    /// <author>Trevor Robinson</author>
    class RevisionAnalyzer : Worker
    {
        private string excludeFiles;
        public string ExcludeFiles
        {
            get { return excludeFiles; }
            set { excludeFiles = value; }
        }

        private readonly VssDatabase database;
        public VssDatabase Database
        {
            get { return database; }
        }

        private readonly LinkedList<VssProject> rootProjects = new LinkedList<VssProject>();
        public IEnumerable<VssProject> RootProjects
        {
            get { return rootProjects; }
        }

        private readonly SortedDictionary<DateTime, ICollection<Revision>> sortedRevisions =
            new SortedDictionary<DateTime, ICollection<Revision>>();
        public SortedDictionary<DateTime, ICollection<Revision>> SortedRevisions
        {
            get { return sortedRevisions; }
        }

        private readonly HashSet<string> processedFiles = new HashSet<string>();
        public HashSet<string> ProcessedFiles
        {
            get { return processedFiles; }
        }

        private readonly HashSet<string> destroyedFiles = new HashSet<string>();
        public HashSet<string> DestroyedFiles
        {
            get { return destroyedFiles; }
        }

        /// <summary>
        /// For each full SS path, record a history of dates that the item was affected,
        /// and the revisions to that item that occurred on each of those dates.
        /// </summary>
        private readonly Dictionary<string, SortedDictionary<DateTime, ICollection<Revision>>> logicalHistory = new Dictionary<string, SortedDictionary<DateTime, ICollection<Revision>>>();
        private readonly Dictionary<string, ICollection<string>> sharedPaths = new Dictionary<string,ICollection<string>>();
        private readonly Dictionary<string, Dictionary<int, Revision>> revisionPathMap = new Dictionary<string,Dictionary<int,Revision>>();

        private int projectCount;
        public int ProjectCount
        {
            get { return Thread.VolatileRead(ref projectCount); }
        }

        private int fileCount;
        public int FileCount
        {
            get { return Thread.VolatileRead(ref fileCount); }
        }

        private int revisionCount;
        public int RevisionCount
        {
            get { return Thread.VolatileRead(ref revisionCount); }
        }

        public RevisionAnalyzer(WorkQueue workQueue, Logger logger, VssDatabase database)
            : base(workQueue, logger)
        {
            this.database = database;
        }

        public bool IsDestroyed(string physicalName)
        {
            return destroyedFiles.Contains(physicalName);
        }

        private class DeleteItem
        {
            public string fullSSPath;
            public string dateInfo;
            public DeleteItem(string fullSSPath, string dateInfo)
            {
                this.fullSSPath = fullSSPath;
                this.dateInfo = dateInfo;
            }
        }

        public void AddItem(VssProject project)
        {
            if (project == null)
            {
                throw new ArgumentNullException("project");
            }
            else if (project.Database != database)
            {
                throw new ArgumentException("Project database mismatch", "project");
            }

            rootProjects.AddLast(project);

            PathMatcher exclusionMatcher = null;
            if (!string.IsNullOrEmpty(excludeFiles))
            {
                var excludeFileArray = excludeFiles.Split(
                    new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
                exclusionMatcher = new PathMatcher(excludeFileArray);
            }

            workQueue.AddLast(delegate(object work)
            {
                logger.WriteSectionSeparator();
                LogStatus(work, "Building revision list");

                logger.WriteLine("Root project: {0}", project.Path);
                logger.WriteLine("Excluded files: {0}", excludeFiles);

                int excludedProjects = 0;
                int excludedFiles = 0;
                var stopwatch = Stopwatch.StartNew();
                VssUtil.RecurseItems(project,
                    delegate(VssProject subproject)
                    {
                        if (workQueue.IsAborting)
                        {
                            return RecursionStatus.Abort;
                        }

                        var path = subproject.Path;
                        if (exclusionMatcher != null && exclusionMatcher.Matches(path))
                        {
                            logger.WriteLine("Excluding project {0}", path);
                            ++excludedProjects;
                            return RecursionStatus.Skip;
                        }

                        ProcessItem(subproject, path, exclusionMatcher);
                        ++projectCount;
                        return RecursionStatus.Continue;
                    },
                    delegate(VssProject subproject, VssFile file)
                    {
                        if (workQueue.IsAborting)
                        {
                            return RecursionStatus.Abort;
                        }

                        var path = file.GetPath(subproject);
                        if (exclusionMatcher != null && exclusionMatcher.Matches(path))
                        {
                            logger.WriteLine("Excluding file {0}", path);
                            ++excludedFiles;
                            return RecursionStatus.Skip;
                        }

                        // only process shared files once (projects are never shared)
                        if (!processedFiles.Contains(file.PhysicalName))
                        {
                            processedFiles.Add(file.PhysicalName);
                            ProcessItem(file, path, exclusionMatcher);
                            ++fileCount;
                        }
                        return RecursionStatus.Continue;
                    });

                foreach (var s in sharedPaths.Values.Distinct())
                {
                    logger.WriteLine("Shared path group: ");
                    foreach (var p in s)
                    {
                        logger.WriteLine(p);
                    }
                }

                Dictionary<Revision, DeleteItem> toDelete = new Dictionary<Revision, DeleteItem>();
                PropagateDeleted(project, project.Path, DateTime.MinValue, DateTime.MinValue, toDelete, exclusionMatcher);
                var deletedPaths = toDelete.ToLookup(d=>d.Value.fullSSPath);
                foreach (var shareGroup in sharedPaths.Values.Distinct())
                {
                    if (shareGroup.Any(d => !deletedPaths.Contains(d)))
                    {
                        foreach (var sharePath in shareGroup)
                        {
                            if (deletedPaths.Contains(sharePath))
                            {
                                foreach (var keepRev in deletedPaths[sharePath])
                                {
                                    logger.WriteLine("Keeping {0} ({1}) of {2} because it is shared",
                                        keepRev.Key.Action.Type.ToString(), keepRev.Key.Version, keepRev.Value.fullSSPath);
                                    toDelete.Remove(keepRev.Key);
                                }
                            }
                        }
                    }
                }
                int removedRevisions = 0;
                foreach (var revDate in sortedRevisions.ToArray())
                {
                    foreach (var revEntry in revDate.Value.ToArray())
                    {
                        if (toDelete.ContainsKey(revEntry))
                        {
                            SortedDictionary<DateTime, ICollection<Revision>> itemHistory;
                            if (logicalHistory.TryGetValue(toDelete[revEntry].fullSSPath, out itemHistory)
                                && (itemHistory.Last().Value.Contains(revEntry)) 
                                && ((revEntry.Action.Type == VssActionType.Delete) || (revEntry.Action.Type == VssActionType.Destroy)))
                            {
                                removedRevisions++;
                                logger.WriteLine("Supressing removal of final delete for {0} ({1}) of {2} ({3}) ", revEntry.Action.Type.ToString(), revEntry.Version, toDelete[revEntry].fullSSPath, toDelete[revEntry].dateInfo);
                            }
                            else
                            {
                                removedRevisions++;
                                logger.WriteLine("Removing {0} ({1}) of {2} ({3}) ", revEntry.Action.Type.ToString(), revEntry.Version, toDelete[revEntry].fullSSPath, toDelete[revEntry].dateInfo);
                                revDate.Value.Remove(revEntry);
                            }
                        }
                    }
                    if (!revDate.Value.Any())
                    {
                        sortedRevisions.Remove(revDate.Key);
                    }
                }
                stopwatch.Stop();

                logger.WriteSectionSeparator();
                logger.WriteLine("Analysis complete in {0:HH:mm:ss}", new DateTime(stopwatch.ElapsedTicks));
                logger.WriteLine("Projects: {0} ({1} excluded)", projectCount, excludedProjects);
                logger.WriteLine("Files: {0} ({1} excluded)", fileCount, excludedFiles);
                logger.WriteLine("Revisions: {0} ({1} excluded for deleted files)", revisionCount, removedRevisions);
            });
        }

        /// <summary>
        /// Recursively collect revision information before the specified date or before any Delete or Destroy revisions
        /// </summary>
        /// <param name="target">VSS file or project tree affected</param>
        /// <param name="path">Full SS path to this item</param>
        /// <param name="deleted">Latest delete operation, before which all revisions should be deleted</param>
        /// <param name="toDelete">Resulting list of revisions that should be deleted</param>
        private void PropagateDeleted(VssItem target, string path, DateTime deleted, DateTime recovered, Dictionary<Revision, DeleteItem> toDelete, PathMatcher exclusionMatcher)
        {
            SortedDictionary<DateTime, ICollection<Revision>> history = logicalHistory[path];
            LinkedList<Revision> accumulated = new LinkedList<Revision>();
            foreach (var revDate in history)
            {
                foreach (var rev in revDate.Value)
                {
                    if ((deleted >= rev.DateTime) && (deleted > recovered))
                    {
                        if (!toDelete.ContainsKey(rev))
                        {
                            toDelete.Add(rev, new DeleteItem(path, string.Format("{0} > {1}",deleted, rev.DateTime)));
                        }
                    }
                    else if (recovered > rev.DateTime)
                    {
                        if (toDelete.ContainsKey(rev))
                            toDelete.Remove(rev);
                    } 
                    else
                    {
                        accumulated.AddLast(rev);
                        switch (rev.Action.Type)
                        {
                            case VssActionType.Delete:
                            case VssActionType.Destroy:
                                foreach(Revision queuedRev in accumulated)
                                {
                                    if (!toDelete.ContainsKey(queuedRev))
                                    {
                                        toDelete.Add(queuedRev, new DeleteItem(path, rev.DateTime.ToShortDateString()));
                                    }
                                }
                                if (rev.DateTime > deleted)
                                    deleted = rev.DateTime;
                                break;
                            case VssActionType.Recover:
                                foreach(Revision queuedRev in accumulated)
                                {
                                    if (toDelete.ContainsKey(queuedRev))
                                    {
                                        toDelete.Remove(queuedRev);
                                    }
                                }
                                if (rev.DateTime > recovered)
                                    recovered = rev.DateTime;
                                break;
                        }
                    }
                }
            }
            var project = target as VssProject;
            if (project != null)
            {
                foreach (VssProject subProject in project.Projects)
                {
                    string targetPath = path + VssDatabase.ProjectSeparatorChar + subProject.Name;
                    if ((exclusionMatcher == null) || !exclusionMatcher.Matches(targetPath))
                        PropagateDeleted(subProject, targetPath, deleted, recovered, toDelete, exclusionMatcher);
                }
                foreach (VssFile file in project.Files)
                {
                    string targetPath = path + VssDatabase.ProjectSeparatorChar + file.Name;
                    if ((exclusionMatcher == null) || !exclusionMatcher.Matches(targetPath))
                        PropagateDeleted(file, targetPath, deleted, recovered, toDelete, exclusionMatcher);
                }
            }
        }

        private void ProcessItem(VssItem item, string path, PathMatcher exclusionMatcher)
        {
            try
            {
                foreach (VssRevision vssRevision in item.Revisions)
                {
                    var actionType = vssRevision.Action.Type;
                    var namedAction = vssRevision.Action as VssNamedAction;
                    var targetPath = path;
                    VssShareAction shareAction = vssRevision.Action as VssShareAction;
                    if (namedAction != null)
                    {
                        switch (actionType)
                        {
                            case VssActionType.Destroy:
                                // track destroyed files so missing history can be anticipated
                                // (note that Destroy actions on shared files simply delete
                                // that copy, so destroyed files can't be completely ignored)
                                destroyedFiles.Add(namedAction.Name.PhysicalName);
                                targetPath = path + VssDatabase.ProjectSeparator + namedAction.Name.LogicalName;
                                break;
                            case VssActionType.Delete:
                            case VssActionType.Recover:
                            case VssActionType.Add:
                            case VssActionType.Rename:
                                targetPath = path + VssDatabase.ProjectSeparator + namedAction.Name.LogicalName;
                                break;
                            case VssActionType.Share:
                                targetPath = path + VssDatabase.ProjectSeparator + namedAction.Name.LogicalName;
                                ICollection<string> targetGroup;
                                string original = shareAction.OriginalProject + VssDatabase.ProjectSeparatorChar + namedAction.Name.LogicalName;
                                if (!sharedPaths.TryGetValue(original, out targetGroup) && !sharedPaths.TryGetValue(targetPath, out targetGroup))
                                {
                                    targetGroup = new LinkedList<string>(new string[] {original});
                                }
                                if (!targetGroup.Contains(targetPath))
                                    targetGroup.Add(targetPath);
                                if (!targetGroup.Contains(original))
                                    targetGroup.Add(original);
                                sharedPaths[targetPath] = targetGroup;
                                break;
                            case VssActionType.Create:
                                targetPath = path;
                                break;
                            case VssActionType.Branch:
                                if (namedAction.Name != null)
                                    targetPath = path + VssDatabase.ProjectSeparator + namedAction.Name.LogicalName;
                                else
                                    targetPath = path;
                                break;

                        }

                        if (exclusionMatcher != null && exclusionMatcher.Matches(targetPath))
                        {
                            // project action targets an excluded file
                            continue;
                        }
                    }

                    Revision revision = new Revision(vssRevision.DateTime,
                        vssRevision.User, item.ItemName, vssRevision.Version,
                        vssRevision.Comment, vssRevision.Action);

                    Dictionary<int, Revision> revList;
                    if (!revisionPathMap.TryGetValue(targetPath, out revList))
                    {
                        revList = new Dictionary<int, Revision>();
                        revisionPathMap[targetPath] = revList;
                    }
                    revList[vssRevision.Version] = revision;

                    ICollection<Revision> revisionSet;
                    if (!sortedRevisions.TryGetValue(vssRevision.DateTime, out revisionSet))
                    {
                        revisionSet = new LinkedList<Revision>();
                        sortedRevisions[vssRevision.DateTime] = revisionSet;
                    }
                    revisionSet.Add(revision);

                    SortedDictionary<DateTime, ICollection<Revision>> logicalNameRevs;
                    if (!logicalHistory.TryGetValue(targetPath, out logicalNameRevs))
                    {
                        logicalNameRevs = new SortedDictionary<DateTime, ICollection<Revision>>();
                        logicalHistory.Add(targetPath, logicalNameRevs);
                    }

                    ICollection<Revision> nameDateRevs;
                    if (!logicalNameRevs.TryGetValue(revision.DateTime, out nameDateRevs))
                    {
                        nameDateRevs = new LinkedList<Revision>();
                        logicalNameRevs.Add(revision.DateTime, nameDateRevs);
                    }

                    nameDateRevs.Add(revision);

                    ++revisionCount;
                }
            }
            catch (RecordException e)
            {
                var message = string.Format("Failed to read revisions for {0} ({1}): {2}",
                    path, item.PhysicalName, ExceptionFormatter.Format(e));
                LogException(e, message);
                ReportError(message);
            }
        }
    }
}

